/**
 * Copyright (c) 2009 Cerner Corporation.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *    Cerner Corporation - initial API and implementation
 */
package com.cerner.system.rest.jaas.cas;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.spi.LoginModule;

import org.restlet.Client;
import org.restlet.Context;
import org.restlet.data.Parameter;
import org.restlet.data.Protocol;
import org.restlet.util.Series;

/**
 * JAAS {@link LoginModule} for the CAS 1.0 authentication protocol. The login
 * module reads the {@code javax.security.auth.login.name} or {@code
 * javax.security.auth.login.password} reads the shared state.
 * <p>
 * The CAS protocol uses HTTP(S) as the transport. The underlying HTTP client
 * BIO socket library can can be tweaked as part of the JAAS configuration:
 * <table>
 * <tr>
 * <th>Parameter name</th>
 * <th>Value type</th>
 * <th>Default value</th>
 * <th>Description</th>
 * </tr>
 * <tr>
 * <td>http.client.tcpNoDelay</td>
 * <td>boolean</td>
 * <td>false</td>
 * <td>Indicate if Nagle's TCP_NODELAY algorithm should be used.</td>
 * </tr>
 * <td>http.client.keystorePath</td>
 * <td>String</td>
 * <td>${user.home}/.keystore</td>
 * <td>SSL keystore path.</td> </tr>
 * <tr>
 * <td>http.client.keystorePassword</td>
 * <td>String</td>
 * <td></td>
 * <td>SSL keystore password.</td>
 * </tr>
 * <tr>
 * <td>http.client.keystoreType</td>
 * <td>String</td>
 * <td>JKS</td>
 * <td>SSL keystore type</td>
 * </tr>
 * <tr>
 * <td>http.client.keyPassword</td>
 * <td>String</td>
 * <td></td>
 * <td>SSL key password.</td>
 * </tr>
 * <tr>
 * <td>http.client.certAlgorithm</td>
 * <td>String</td>
 * <td>SunX509</td>
 * <td>SSL certificate algorithm.</td>
 * </tr>
 * <tr>
 * <td>http.client.secureRandomAlgorithm</td>
 * <td>String</td>
 * <td>null (see java.security.SecureRandom)</td>
 * <td>Name of the RNG algorithm. (see java.security.SecureRandom class).</td>
 * </tr>
 * <tr>
 * <td>http.client.securityProvider</td>
 * <td>String</td>
 * <td>null (see javax.net.ssl.SSLContext)</td>
 * <td>Java security provider name (see java.security.Provider class).</td>
 * </tr>
 * <tr>
 * <td>http.client.sslProtocol</td>
 * <td>String</td>
 * <td>TLS</td>
 * <td>SSL protocol.</td>
 * </tr>
 * <tr>
 * <td>http.client.truststoreType</td>
 * <td>String</td>
 * <td>null</td>
 * <td>Trust store type</td>
 * </tr>
 * <tr>
 * <td>http.client.truststorePath</td>
 * <td>String</td>
 * <td>null</td>
 * <td>Path to trust store</td>
 * </tr>
 * <tr>
 * <td>http.client.truststorePassword</td>
 * <td>String</td>
 * <td>null</td>
 * <td>Trust store password</td>
 * </tr>
 * </table>
 * 
 * @author Alex Horn
 * @since 1.0.0
 */
public class CasLoginModule implements LoginModule {

  /**
   * Key for the username as stored in shared state.
   */
  private static final String USERNAME_KEY = "javax.security.auth.login.name";

  /**
   * Key for the password as stored in shared state.
   */
  private static final String PASSWORD_KEY = "javax.security.auth.login.password";

  /**
   * Prefix for JAAS configuration options that tweak the settings of the HTTP
   * client library.
   */
  private static final String HTTP_CLIENT_KEY = "http.client.";

  /**
   * Identifier for {@code casLoginUrl} JAAS configuration options.
   */
  private static final String CAS_LOGIN_URL = "casLoginUrl";

  /**
   * Internal dependency on the module that is responsible for sending and
   * receiving HTTP data.
   */
  private final CasCredentialConnector casCredentialConnector;

  /**
   * Factory for {@link CasTicketConnector} objects.
   */
  private final CasTicketConnectorProvider casTicketConnectorProvider;

  /**
   * Construct a {@link CasLoginUrlCallback} at runtime if the {@code
   * casLoginUrl} could not be discovered through the {@link #CAS_LOGIN_URL}
   * JAAS configuration.
   */
  private final CallbackProvider<CasLoginUrlCallback> casLoginUrlCallbackProvider;

  /**
   * Construct a {@link NameCallback} at runtime if the user name or password
   * could not be discovered via shared variables.
   */
  private final CallbackProvider<NameCallback> nameCallbackProvider;

  /**
   * Construct a {@link PasswordCallback} at runtime if the user name or
   * password could not be discovered via shared variables.
   */
  private final CallbackProvider<PasswordCallback> passwordCallbackProvider;

  /**
   * Allow configuration of the underlying HTTP client library.
   */
  private final Client client;

  /**
   * The Subject to which credentials are attached. It is only set once with
   * {@link #initialize(Subject, CallbackHandler, Map, Map)}. The
   * {@link #casCredential} is added when invoking {@link #commit()}.
   */
  private Subject subject;

  /**
   * Callback handler to produce security information. It is only set once with
   * {@link #initialize(Subject, CallbackHandler, Map, Map)}.
   */
  private CallbackHandler callbackHandler;

  /**
   * JAAS shared state, as provided to the
   * {@link #initialize(Subject, CallbackHandler, Map, Map)} method.
   */
  private Map<String, ?> sharedState;

  /**
   * Unmodifiable map that represents the JAAS configuration options. It is only
   * set once with {@link #initialize(Subject, CallbackHandler, Map, Map)}.
   */
  private Map<String, ?> options;

  /**
   * Instance field that is initially {@code null}. It is set if
   * {@link #login()} successfully authenticates the user. An invocation of
   * {@link #commit()} actually adds it to the {link {@link #subject}.
   */
  private CasCredential casCredential;

  /**
   * Internal constructor that allows for dependency injection.
   */
  CasLoginModule(Client client, CasCredentialConnector casLoginProducer,
      CasTicketConnectorProvider casTicketProducerProvider,
      CallbackProvider<CasLoginUrlCallback> serviceUrlCallbackProvider,
      CallbackProvider<NameCallback> nameCallbackProvider,
      CallbackProvider<PasswordCallback> passwordCallbackProvider) {

    this.client = client;
    this.casCredentialConnector = casLoginProducer;
    this.casTicketConnectorProvider = casTicketProducerProvider;
    this.casLoginUrlCallbackProvider = serviceUrlCallbackProvider;
    this.nameCallbackProvider = nameCallbackProvider;
    this.passwordCallbackProvider = passwordCallbackProvider;
  }

  /**
   * Instantiate a new JAAS {@link LoginModule} that is able to authenticate
   * users with the CAS 1.0 protocol.
   */
  public CasLoginModule(final Client client) {
    /* initialize HTTP transport layer components */
    this.client = client;
    this.casCredentialConnector = new CasCredentialConnector(client);
    this.casTicketConnectorProvider = new CasTicketConnectorProvider(client);

    /* initialize JAAS Callback objects */
    this.casLoginUrlCallbackProvider = provide(new CasLoginUrlCallback(
        "Service URI"));
    this.nameCallbackProvider = provide(new NameCallback("Username"));
    this.passwordCallbackProvider = provide(new PasswordCallback("Password",
        false));
  }

  /**
   * Instantiate a new JAAS {@link LoginModule} that is able to authenticate
   * users with the CAS 1.0 protocol. This constructor will attempt to use the
   * current thread-local Restlet context if it is not {@code null}. Otherwise
   * it will create a pristine context.
   */
  public CasLoginModule() {
    this(new Client(Context.getCurrent() == null
        ? new Context()
          : Context.getCurrent(), Protocol.HTTPS));
  }

  @SuppressWarnings( { "hiding" /* note "this" prefix */})
  public void initialize(Subject subject, CallbackHandler callbackHandler,
      Map<String, ?> sharedState, Map<String, ?> options) {
    this.subject = subject;
    this.callbackHandler = callbackHandler;
    this.sharedState = sharedState;
    this.options = options;

    assert this.subject != null;
    assert this.sharedState != null;
    assert this.options != null;

    final Context context = this.client.getContext();
    if (context != null) {

      /*
       * Copy all JAAS configuration settings that start with "http.client" to
       * the Restlet context parameter list after stripping of the prefix.
       */
      final int keyOffset = HTTP_CLIENT_KEY.length();
      final Series<Parameter> parameters = context.getParameters();
      for (Entry<String, ?> option : this.options.entrySet()) {
        if (option.getKey().startsWith(HTTP_CLIENT_KEY)) {
          final String httpClientKey = option.getKey().substring(keyOffset);
          final String httpClientValue = (String) option.getValue();
          parameters.add(httpClientKey, httpClientValue);
        }
      }
    }
  }

  public boolean login() throws CasLoginException {
    URI casLoginUrl = null;
    String username = null;
    char[] password = null;

    /* Read JAAS configuration options to set casLoginUrl. */
    final String rawCasLoginUrl = (String) this.options.get(CAS_LOGIN_URL);
    if (rawCasLoginUrl != null) {
      try {
        casLoginUrl = new URI(rawCasLoginUrl);
      } catch (URISyntaxException e) {
        throw new CasFailedLoginException(e);
      }
    }

    /* Shared upstream state information has priority. */
    username = (String) this.sharedState.get(USERNAME_KEY);
    password = (char[]) this.sharedState.get(PASSWORD_KEY);

    /* Fall back to discovery of missing information with CallbackHandler. */
    if (casLoginUrl == null || username == null || password == null) {
      CasLoginUrlCallback casLoginUrlCallback = null;

      final List<Callback> callbacks = new ArrayList<Callback>(3 /* three */);
      if (casLoginUrl == null) {
        casLoginUrlCallback = this.casLoginUrlCallbackProvider.get();
        callbacks.add(casLoginUrlCallback);
      }

      NameCallback nameCallback = null;
      PasswordCallback passwordCallback = null;
      if (username == null || password == null) {
        nameCallback = this.nameCallbackProvider.get();
        passwordCallback = this.passwordCallbackProvider.get();

        callbacks.add(passwordCallback);
        callbacks.add(nameCallback);
      }

      if (!callbacks.isEmpty()) {
        if (this.callbackHandler == null) {
          throw new CasFailedLoginException(
              "Credentials or casLoginUrl need to be retrieved via CallbackHandler but none was specified");
        } else {
          try {
            this.callbackHandler.handle(callbacks.toArray(new Callback[callbacks.size()]));
          } catch (IOException e) {
            throw new CasFailedLoginException(e);
          } catch (UnsupportedCallbackException e) {
            throw new CasFailedLoginException(e);
          }

          if (casLoginUrl == null) {
            assert casLoginUrlCallback != null;

            casLoginUrl = casLoginUrlCallback.getCasLoginUrl();
          }

          if (username == null || password == null) {
            assert nameCallback != null;
            assert passwordCallback != null;

            username = nameCallback.getName();
            password = passwordCallback.getPassword();
            passwordCallback.clearPassword();
          }
        }
      }
    }

    final boolean isCasLoginUrlNull = casLoginUrl == null;
    final boolean isUsernameNull = username == null;
    final boolean isPasswordNull = password == null;

    if (isCasLoginUrlNull || isUsernameNull || isPasswordNull) {
      throw new CasLoginPrepareException(isCasLoginUrlNull, isUsernameNull,
          isPasswordNull);
    } else {
      final String casTicketGrantingTicket;
      try {
        casTicketGrantingTicket = this.casCredentialConnector.login(
            casLoginUrl, username, password);
      } catch (CasConnectorException e) {
        throw new CasLoginException(e);
      } catch (CasConnectorFault e) {
        throw new CasLoginException(e);
      }
      final CasTicketConnector casTicketSender = this.casTicketConnectorProvider.get(
          casLoginUrl, casTicketGrantingTicket);

      this.casCredential = new CasCredential(casTicketSender, username,
          password);

      return true;
    }
  }

  public boolean commit() throws CasFailedLoginException {
    if (!isLoggedIn()) {
      return false;
    } else if (this.subject.isReadOnly()) {
      throw new CasFailedLoginException("Subject is read only");
    } else {
      return this.subject.getPrivateCredentials().add(this.casCredential);
    }
  }

  public boolean abort() {
    if (!isLoggedIn()) {
      return false;
    } else {

      /*
       * Delegate to logout to clean the subject and destroy credentials. This
       * is needed because the overall login can be aborted after we've
       * committed to the Subject.
       */
      return logout();
    }
  }

  public boolean logout() {
    if (!isLoggedIn()) {
      return false;
    } else {
      try {
        if (this.subject.isReadOnly()) {
          return false;
        } else {
          return this.subject.getPrivateCredentials().remove(this.casCredential);
        }
      } finally {
        this.casCredential = null;
      }
    }
  }

  /**
   * Indicates whether or not the login module is currently logged in.
   * 
   * @return a boolean {@code true} if logged in, otherwise {@code false}
   */
  boolean isLoggedIn() {
    return this.casCredential != null;
  }

  /**
   * Internal API to instantiate a {@link CallbackProvider} that produces a
   * singleton JAAS {@link Callback} object.
   */
  private static <T extends javax.security.auth.callback.Callback> CallbackProvider<T> provide(
      final T callback) {

    return new CallbackProvider<T>() {

      public T get() {
        return callback;
      }

    };
  }
}
